Using MAUI for creating a real application presents a few real challenges. Creating a multiplatform application is not fully supported, unlike the RefreshView component.
.NET MAUI is a powerful cross-platform UI framework, but not all controls are supported on every platform. One notable limitation is the absence of RefreshView
support on macOS. This can be a blocker for developers who want a consistent pull-to-refresh experience across all platforms.
In this post, I will:
- Explain the issue with
RefreshView
on macOS. - Show how to build a cross-platform
CustomRefreshView
for .NET MAUI. - Provide step-by-step code and XAML integration.
The full source code of this component is available on GitHub.
The Problem: RefreshView on macOS
The built-in RefreshView
control in .NET MAUI enables pull-to-refresh functionality for scrollable content. However, as of .NET MAUI 9, RefreshView
is not supported on macOS. Attempting to use it will result in runtime errors or simply no refresh gesture support.
Why does this happen?
- The underlying gesture and native control implementation for
RefreshView
is missing on macOS. - The official documentation and GitHub issues confirm this limitation.
Impact
- macOS users cannot trigger refresh actions via pull gestures.
- UI consistency and user experience are affected.
Here some Microsoft documentation about it:
- Specify the UI idiom for your Mac Catalyst app – .NET MAUI | Microsoft Learn
- RefreshView Class (Microsoft.Maui.Controls) | Microsoft Learn
- Customize UI appearance based on the platform and device idiom – .NET MAUI | Microsoft Learn
Warning
UIStepper, UIPickerView, and UIRefreshControl aren’t supported in the Mac user interface idiom by Apple. This means that the .NET MAUI controls that consume these native controls are not usable in the Mac user interface idiom. These include the Stepper, Picker, and RefreshView. Attempting to do so will throw a macOS exception.
In addition, the following constraints apply in the Mac user interface idiom:
- UISwitch throws a macOS exception when it’s title is set in a non-Mac idiom view.
- UIButton throws a macOS exception when AddGestureRecognizer is called, or when SetTitle or SetImage are called for any state except
UIControlStateNormal.Normal
. - UISlider throws a macOS exception when the SetThumbImage, SetMinTrackImage, SetMaxTrackImage methods are called and when the ThumbTintColor, MinimumTrackTintColor, MaximumTrackTintColor, MinValueImage, MaxValueImage properties are set.
Solution: Build a CustomRefreshView
To overcome this, you can create a custom control. It should mimic the core features of RefreshView
and work on all platforms. This includes macOS.
Features to Implement
- Pull-to-refresh gesture detection
- Visual feedback (spinner and optional text)
- Command execution on refresh
- Bindable properties for customization (color, position, background, etc.)
Step 1: Create the CustomRefreshView Class
Create a new file CustomRefreshView.cs
in your MAUI project.
using System; using Microsoft.Maui.Controls;
namespace YourApp.Components { public enum Position { Top, Middle, Bottom }
public class CustomRefreshView : ContentView
{
// Bindable properties for IsRefreshing, RefreshCommand, RefreshColor, etc.
// See full code below for all properties
// Internal controls
private readonly ActivityIndicator _activityIndicator;
private readonly Label _indicatorLabel;
private readonly Grid _grid;
private readonly VerticalStackLayout _indicatorStack;
private double _totalY;
public CustomRefreshView()
{
// Gesture detection
var panGesture = new PanGestureRecognizer();
panGesture.PanUpdated += OnPanUpdated;
GestureRecognizers.Add(panGesture);
// Spinner
_activityIndicator = new ActivityIndicator
{
IsVisible = false,
IsRunning = false,
VerticalOptions = LayoutOptions.Center,
HorizontalOptions = LayoutOptions.Center,
InputTransparent = true
};
_activityIndicator.SetBinding(ActivityIndicator.ColorProperty, new Binding(nameof(RefreshColor), source: this));
// Optional text
_indicatorLabel = new Label
{
IsVisible = false,
VerticalOptions = LayoutOptions.Center,
HorizontalOptions = LayoutOptions.Center
};
// Stack for spinner and text
_indicatorStack = new VerticalStackLayout
{
HorizontalOptions = LayoutOptions.Center,
VerticalOptions = LayoutOptions.Center,
IsVisible = false,
Children = { _activityIndicator, _indicatorLabel }
};
// Grid for positioning
_grid = new Grid
{
RowDefinitions =
{
new RowDefinition { Height = GridLength.Star },
new RowDefinition { Height = GridLength.Star },
new RowDefinition { Height = GridLength.Star }
}
};
_grid.Children.Add(_indicatorStack);
Grid.SetRow(_indicatorStack, 1); // Default: Middle
Content = _grid;
}
// ... Bindable properties and property changed handlers ...
// See full code below
}
Step 2: Add Bindable Properties
Add properties for customization and MVVM support:
IsRefreshing
(bool)RefreshCommand
(Command)RefreshColor
(Color)IndicatorText
(string)IndicatorTextColor
(Color)IndicatorBackground
(Color)IndicatorPosition
(enum: Top, Middle, Bottom)IndicatorMargin
,IndicatorMinimumWidthRequest
,IndicatorMinimumHeightRequest
(layout)
This is an example for the IsRefreshing
property
public static readonly BindableProperty IsRefreshingProperty =
BindableProperty.Create(
nameof(IsRefreshing),
typeof(bool),
typeof(CustomRefreshView),
false,
propertyChanged: OnIsRefreshingChanged);
public bool IsRefreshing
{
get => (bool)GetValue(IsRefreshingProperty);
set => SetValue(IsRefreshingProperty, value);
}
Step 3: Handle the Pull-to-Refresh Gesture
Detect a downward pan gesture and trigger the refresh command:
private void OnPanUpdated(object sender, PanUpdatedEventArgs e)
{
switch (e.StatusType)
{
case GestureStatus.Started:
_totalY = 0;
break;
case GestureStatus.Running:
_totalY += e.TotalY;
break;
case GestureStatus.Completed:
if (_totalY > 50)
RefreshCommand?.Execute(null);
break;
}
}
Step 4: Show Spinner and Text When Refreshing
Update visibility and appearance based on IsRefreshing
and other properties:
private static void OnIsRefreshingChanged(BindableObject bindable, object oldValue, object newValue)
{
if (bindable is CustomRefreshView control)
{
bool isRefreshing = (bool)newValue;
control._activityIndicator.IsRunning = isRefreshing;
control._activityIndicator.IsVisible = isRefreshing;
control._indicatorLabel.IsVisible = isRefreshing && !string.IsNullOrEmpty(control.IndicatorText);
control._indicatorStack.IsVisible = isRefreshing;
}
}
Step 5: Position the Indicator Group
Use the grid to position the indicator at the top, middle, or bottom:
private static void OnIndicatorPositionChanged(BindableObject bindable, object oldValue, object newValue)
{
var control = (CustomRefreshView)bindable;
var position = (Position)newValue;
switch (position)
{
case Position.Top:
control._indicatorStack.VerticalOptions = LayoutOptions.Start;
break;
case Position.Bottom:
control._indicatorStack.VerticalOptions = LayoutOptions.End;
break;
default:
control._indicatorStack.VerticalOptions = LayoutOptions.Center;
break;
}
}
Step 6: Use the CustomRefreshView in XAML
<components:CustomRefreshView
IndicatorBackground="{StaticResource Gray100}"
IndicatorPosition="Top"
IndicatorText="Loading data..."
IndicatorTextColor="{StaticResource OffBlack}"
IsRefreshing="{Binding IsRefreshing}"
RefreshColor="{AppThemeBinding Light={StaticResource Primary10},
Dark={StaticResource Primary10}}"
RefreshCommand="{Binding RefreshCommand}">
<components:CustomRefreshView.RefreshContent>
<!-- Add here the content of the page -->
</components:CustomRefreshView.RefreshContent>
</components:CustomRefreshView>
Conclusion
By building a CustomRefreshView
, you can provide a consistent pull-to-refresh experience across all .NET MAUI platforms, including macOS. This approach is flexible, customizable, and future-proof for your cross-platform applications.
Key takeaways:
RefreshView
is not supported on macOS in .NET MAUI.- A custom control can replicate its functionality using gesture detection and MVVM-friendly properties.
- The solution is fully cross-platform and highly customizable.
Happy coding!